How to Build an Audiobook Player App in SwiftUI

An audiobook player lets users import MP3 and M4B files, resume exactly where they left off, and drop bookmarks at key passages. It's ideal for developers building language-learning tools, self-improvement apps, or alternatives to first-party players like Apple Books.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

The data layer uses SwiftData to persist Book and Bookmark models; each book stores its file URL pointing to the app's document directory. AudioPlayerService is a @Observable singleton that owns the AVPlayer instance, activates the audio session, and writes Now Playing metadata via MPNowPlayingInfoCenter. Views observe the service and the current book separately so the scrubber ticks every second without re-rendering the full library list.

AudiobookApp/
├── Models/
│   ├── Book.swift              # @Model — title, author, fileURL, position
│   └── Bookmark.swift          # @Model — position, note, createdAt
├── Views/
│   ├── LibraryView.swift       # @Query list + .fileImporter sheet
│   ├── PlayerView.swift        # scrubber, transport, bookmarks
│   └── BookDetailView.swift
├── Services/
│   └── AudioPlayerService.swift  # @Observable, AVPlayer, MPRemote
└── AudiobookApp.swift

Step-by-step

1. Data model

Define Book and Bookmark with SwiftData's @Model macro so playback position and bookmarks survive app restarts automatically.

import SwiftData
import Foundation

@Model final class Book {
    var id: UUID = UUID()
    var title: String
    var author: String
    var fileURL: URL?
    var coverData: Data?
    var currentPosition: Double = 0   // seconds
    var duration: Double = 0
    var lastPlayedAt: Date?
    var isFinished: Bool = false

    @Relationship(deleteRule: .cascade)
    var bookmarks: [Bookmark] = []

    init(title: String, author: String) {
        self.title = title
        self.author = author
    }
}

@Model final class Bookmark {
    var id: UUID = UUID()
    var position: Double
    var note: String
    var createdAt: Date = Date.now

    init(position: Double, note: String = "") {
        self.position = position
        self.note = note
    }
}

2. Core UI — PlayerView

Build the playback screen with a live scrubber, skip controls, and a one-tap bookmark button, all driven by the shared AudioPlayerService from the environment.

struct PlayerView: View {
    @Bindable var book: Book
    @Environment(AudioPlayerService.self) var player

    var body: some View {
        VStack(spacing: 24) {
            if let data = book.coverData, let img = UIImage(data: data) {
                Image(uiImage: img)
                    .resizable().aspectRatio(1, contentMode: .fit)
                    .frame(maxWidth: 260).clipShape(RoundedRectangle(cornerRadius: 16))
            }
            VStack(spacing: 4) {
                Text(book.title).font(.headline)
                Text(book.author).font(.subheadline).foregroundStyle(.secondary)
            }
            Slider(value: $book.currentPosition, in: 0...max(book.duration, 1)) { editing in
                if !editing { player.seek(to: book.currentPosition) }
            }
            HStack {
                Text(Duration.seconds(book.currentPosition),
                     format: .units(width: .condensedAbbreviated))
                Spacer()
                Text(Duration.seconds(book.duration),
                     format: .units(width: .condensedAbbreviated))
            }.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
            HStack(spacing: 40) {
                Button { player.skip(-15) } label: { Image(systemName: "gobackward.15").font(.title) }
                Button { player.isPlaying ? player.pause() : player.play(book) } label: {
                    Image(systemName: player.isPlaying ? "pause.fill" : "play.fill").font(.largeTitle)
                }
                Button { player.skip(30) } label: { Image(systemName: "goforward.30").font(.title) }
            }
            Button("Add Bookmark") { player.addBookmark() }.buttonStyle(.borderedProminent)
        }.padding()
    }
}

3. Library with bookmarks — AudioPlayerService

Configure AVAudioSession for background playback, attach a periodic time observer that writes position back to SwiftData, and expose an addBookmark() method that captures the live playhead.

import AVFoundation, MediaPlayer, Observation

@Observable final class AudioPlayerService {
    private(set) var isPlaying = false
    private(set) var currentBook: Book?
    private var player: AVPlayer?
    private var timeObserver: Any?

    func play(_ book: Book) {
        guard let url = book.fileURL else { return }
        try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
        try? AVAudioSession.sharedInstance().setActive(true)
        if currentBook?.id != book.id {
            player = AVPlayer(url: url)
            let iv = CMTime(seconds: 1, preferredTimescale: 600)
            timeObserver = player?.addPeriodicTimeObserver(forInterval: iv, queue: .main) { [weak self] t in
                book.currentPosition = t.seconds
                if let d = self?.player?.currentItem?.duration, d.isNumeric { book.duration = d.seconds }
            }
        }
        player?.seek(to: CMTime(seconds: book.currentPosition, preferredTimescale: 600))
        player?.play(); isPlaying = true; currentBook = book
        MPNowPlayingInfoCenter.default().nowPlayingInfo = [
            MPMediaItemPropertyTitle: book.title,
            MPMediaItemPropertyArtist: book.author,
            MPNowPlayingInfoPropertyElapsedPlaybackTime: book.currentPosition,
            MPMediaItemPropertyPlaybackDuration: book.duration
        ]
    }
    func addBookmark() {
        guard let book = currentBook, let pos = player?.currentTime().seconds else { return }
        book.bookmarks.append(Bookmark(position: pos))
    }
    func pause() { player?.pause(); isPlaying = false }
    func skip(_ s: Double) {
        guard let cur = player?.currentTime().seconds else { return }
        player?.seek(to: CMTime(seconds: cur + s, preferredTimescale: 600))
    }
    func seek(to s: Double) { player?.seek(to: CMTime(seconds: s, preferredTimescale: 600)) }
}

4. Privacy Manifest (PrivacyInfo.xcprivacy)

Add a PrivacyInfo.xcprivacy file to your app target — App Store Connect rejects uploads that access file timestamp APIs without an explicit reason code.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key><false/>
  <key>NSPrivacyTrackingDomains</key><array/>
  <key>NSPrivacyCollectedDataTypes</key><array/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>C617.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Subscription

Use StoreKit 2's Product.products(for:) to load subscription SKUs you've configured in App Store Connect under Monetization → Subscriptions, then gate premium features — unlimited library size, variable playback speed, sleep timer — behind a Transaction.currentEntitlement(for:) check, which resolves locally without a network call. Build your paywall with the ready-made SubscriptionStoreView (iOS 17+) or a custom sheet. Observe Transaction.updates as an AsyncSequence in a long-lived task so renewals, upgrades, and revocations are handled automatically at runtime without polling.

Shipping this faster with Soarias

Soarias generates the SwiftData models, the AudioPlayerService shell (including the AVAudioSession category and Background Modes entitlement), and a pre-filled PrivacyInfo.xcprivacy as part of project scaffolding — the three areas that take intermediate developers the most unplanned debugging time. It also configures fastlane deliver with your App Store Connect API key so screenshots, metadata, and your binary ship in one command.

For a one-week intermediate project like this, the typical time sink is 1–2 days on AVPlayer edge cases and another half-day wrestling with App Store Connect metadata requirements. Soarias compresses that overhead to a couple of hours, freeing the rest of the week for differentiating features like chapter navigation, sleep timers, and variable playback speed.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. The free tier lets you side-load to your own device via Xcode, but TestFlight distribution and App Store submission both require the $99/year Apple Developer Program membership. You'll also need it to create the StoreKit subscription products in App Store Connect.

How do I submit this to the App Store?

Archive the app in Xcode (Product → Archive), upload via the Organizer, then complete the App Store Connect listing: add screenshots for all required device sizes, fill in privacy nutrition labels, create your subscription products under Monetization → Subscriptions, and submit for review. First-time submissions typically take 1–3 business days.

What audio formats does AVPlayer support for audiobooks?

AVPlayer handles MP3, AAC (.m4a), Apple Lossless (ALAC), and the audiobook-specific M4B container (AAC with embedded chapter markers). For M4B chapter support, call AVAsset.chapterMetadataGroups(withTitleLocale:containingItemsWithCommonKeys:) on the asset and surface the chapters alongside your bookmarks list in the UI.

Last reviewed: 2026-05-12 by the Soarias team.