Swift Concurrency for AVPlayer: Actors and async/await

8 min read
Share:

Introduction

Here’s a bug you might recognise. A user taps “Next episode” while the current one is still loading, and a second later an error screen pops up over the new episode. The error actually belongs to the previous one, which failed late.

Bugs like this are easy to blame on AVFoundation, but they’re really ordering problems: events arriving after the thing they belong to is gone. Working on OTT apps for iPhone and Apple TV, I’ve settled on a player structure that makes most of them go away, and Swift Concurrency is what makes it practical. Let me walk you through it.

Prerequisites

  1. Xcode 15 or later (Swift 5.9+).
  2. An iOS 16 / tvOS 16 deployment target.
  3. You’re comfortable with AVPlayer and know the basics of Swift Concurrency.

Why is a video player so hard to get right?

Because a lot is happening at the same time. During a single playback session, your code is hearing from:

  • KVO on AVPlayerItem.status and AVPlayer.timeControlStatus
  • the periodic time observer
  • asset loading, plus network calls for entitlements, DRM and resume positions
  • the user (play, pause, seek, next episode) and the app lifecycle

None of these wait for each other. That’s how you end up with late errors from the previous item, resume positions saved twice, or a buffering spinner that just never disappears. Which one has bitten you?

How I split the responsibilities

The idea is simple. AVPlayer stays on the main actor. Playback state lives in actors. Loading happens off the main thread. Each piece has one isolation domain, so the compiler checks every hop between them for you.

Component Isolation Owns
PlayerController @MainActor class AVPlayer and observer tasks
AssetLoader nonisolated struct Nothing; returns Sendable results
PlaybackStateStore actor The playback state machine
ResumePositionStore actor Pending resume positions

Why not put AVPlayer in an actor too? It isn’t Sendable, AVPlayerLayer wants the main thread anyway, and every play() would become an await.

1. A state actor that ignores stale events

This is the piece that fixes the “late error from the previous episode” bug. All state changes go through one actor, and every event carries the ID of the item it belongs to. If the ID doesn’t match the item that’s currently active, the event is simply dropped.

enum PlaybackState: Equatable, Sendable {
    case idle
    case loading(itemID: String), playing(itemID: String), paused(itemID: String)
    case buffering(itemID: String), ended(itemID: String)
    case ready(itemID: String, isLive: Bool)
    case failed(itemID: String, message: String)
}

actor PlaybackStateStore {
    private(set) var state: PlaybackState = .idle
    private var activeItemID: String?
    private var continuations: [UUID: AsyncStream<PlaybackState>.Continuation] = [:]

    func begin(itemID: String) {
        activeItemID = itemID
        publish(.loading(itemID: itemID))
    }

    func apply(_ newState: PlaybackState, for itemID: String) {
        guard itemID == activeItemID else { return }   // stale event, drop it
        guard newState != state else { return }        // de-duplicate KVO noise
        publish(newState)
    }

    func updates() -> AsyncStream<PlaybackState> {
        let id = UUID()
        let (stream, continuation) = AsyncStream.makeStream(
            of: PlaybackState.self, bufferingPolicy: .bufferingNewest(1))
        continuation.yield(state)
        continuations[id] = continuation
        continuation.onTermination = { [weak self] _ in
            Task { await self?.removeSubscriber(id) }
        }
        return stream
    }

    private func publish(_ newState: PlaybackState) {
        state = newState
        continuations.values.forEach { $0.yield(newState) }
    }

    private func removeSubscriber(_ id: UUID) { continuations[id] = nil }
}

I use .bufferingNewest(1) because the UI only ever cares about the latest state, not a backlog. And there’s no await anywhere inside the actor, so a state change can’t be interrupted halfway through.

2. Loading assets (and actually cancelling them)

If you’re still using loadValuesAsynchronously(forKeys:), the async AVAsset.load(_:) API that arrived in iOS 15 is much nicer. You can load several properties in one call, and you get real errors back.

struct LoadedAsset: @unchecked Sendable {
    let asset: AVURLAsset   // not mutated after loading
    let isLive: Bool
}

enum AssetLoadError: Error { case notPlayable }

struct AssetLoader {
    func load(url: URL) async throws -> LoadedAsset {
        let asset = AVURLAsset(url: url)
        return try await withTaskCancellationHandler {
            let (isPlayable, duration) = try await asset.load(.isPlayable, .duration)
            try Task.checkCancellation()
            guard isPlayable else { throw AssetLoadError.notPlayable }
            return LoadedAsset(asset: asset, isLive: duration.isIndefinite)
        } onCancel: {
            asset.cancelLoading()
        }
    }
}

The part I like most is the cancellation handler. When the user switches episodes, the task gets cancelled, cancelLoading() runs, and the old playlist stops downloading in the background. Also, an indefinite duration means the stream is live, so you know early whether to show a seek bar or a LIVE badge.

3. Putting it together in the controller

The controller is a @MainActor class that owns the player. Every call to load cancels whatever was loading before it.

@MainActor
final class PlayerController {
    let player = AVPlayer()
    private let loader = AssetLoader()
    private let stateStore: PlaybackStateStore
    private var loadTask: Task<Void, Never>?
    private var observers: [Task<Void, Never>] = []

    init(stateStore: PlaybackStateStore) { self.stateStore = stateStore }

    func load(itemID: String, url: URL) {
        loadTask?.cancel()
        observers.forEach { $0.cancel() }
        observers.removeAll()

        loadTask = Task {
            await stateStore.begin(itemID: itemID)
            do {
                let loaded = try await loader.load(url: url)
                try Task.checkCancellation()   // a newer load() may have started
                player.replaceCurrentItem(with: AVPlayerItem(asset: loaded.asset))
                observeTimeControlStatus(itemID: itemID)
                await stateStore.apply(.ready(itemID: itemID, isLive: loaded.isLive), for: itemID)
                player.play()
            } catch is CancellationError {
                // Superseded by a newer load. Nothing to report.
            } catch {
                await stateStore.apply(.failed(itemID: itemID,
                    message: error.localizedDescription), for: itemID)
            }
        }
    }

    private func observeTimeControlStatus(itemID: String) {
        let store = stateStore
        observers.append(Task { [player] in
            for await status in player.publisher(for: .timeControlStatus).values {
                let state: PlaybackState = switch status {
                case .playing: .playing(itemID: itemID)
                case .waitingToPlayAtSpecifiedRate: .buffering(itemID: itemID)
                default: .paused(itemID: itemID)
                }
                await store.apply(state, for: itemID)
            }
        })
    }
}

Because the Task inherits main-actor isolation from the class, I can touch player after each await without any DispatchQueue.main.async. The checkCancellation() after loading stops an outdated load from replacing the item the user actually wants. If you’re using SwiftUI, call load from .task(id: episode.id) and the “next episode while loading” case is handled for you.

4. The bug actors don’t protect you from

This one took me a while to properly understand. An actor makes sure only one call touches its state at a time, but it can’t make sure your assumptions still hold after an await. Every await inside an actor is a point where other calls can sneak in. Here’s a flush function for saving resume positions that looks perfectly fine:

func flush() async {   // BUGGY
    guard !pending.isEmpty else { return }
    try? await api.save(pending)   // suspends here
    pending.removeAll()            // wipes positions recorded during the await
}

The trouble starts while api.save is waiting on the network. New positions keep coming in, and a second flush can start and send the same batch again. Then, when the first save finally returns, removeAll() wipes positions that were never sent. On office Wi-Fi you’ll never see it, which is exactly why it gets past QA.

The fix is to finish changing state before you suspend, and be careful about what you assume after:

actor ResumePositionStore {
    private var pending: [String: Double] = [:]
    private var isFlushing = false
    private let api: ProgressAPI

    init(api: ProgressAPI) { self.api = api }

    func record(_ seconds: Double, for itemID: String) {
        pending[itemID] = seconds
    }

    func flush() async {
        guard !isFlushing, !pending.isEmpty else { return }
        let batch = pending            // 1. update state before suspending
        pending.removeAll()
        isFlushing = true
        defer { isFlushing = false }
        do {
            try await api.save(batch)  // 2. suspend
        } catch {
            pending.merge(batch) { newer, _ in newer }   // 3. keep newer positions
        }
    }
}

These days I read every await inside an actor as “state might be different after this line”. Take a snapshot before, and don’t assume anything after.

A few mistakes to avoid

  • Don’t hop into an actor on every frame. Half a second to a second is plenty for a scrubber, and 5–10 seconds is fine for resume positions.
  • Swift won’t stop a cancelled task for you. After an await, check for cancellation yourself before you touch the player.
  • Cancel your observer tasks when the item changes. Waiting for deinit usually means waiting forever.
  • If you move to Swift 6.2 with NonisolatedNonsendingByDefault on, mark AssetLoader.load as @concurrent (SE-0461), otherwise loading will quietly start running on the main actor.

Conclusion

Swift Concurrency won’t make a video player simple. What it does is make the rules something the compiler can check instead of something you have to remember. If you take only three things from this post: keep AVPlayer on the main actor, tag every event with its item ID, and be suspicious of every await inside an actor. That alone gets rid of most “this should never happen” bugs.

Leave a Reply

Your email address will not be published. Required fields are marked *