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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Physical iPhone for testing —
AVAudioSessionbackground behavior differs in Simulator - Background Modes entitlement enabled under Signing & Capabilities → "Audio, AirPlay, and Picture in Picture"
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
- Missing Background Modes entitlement. If "Audio, AirPlay, and Picture in Picture" isn't checked in Signing & Capabilities, iOS silently pauses playback on backgrounding — no error thrown, just silence.
- Plain URL goes stale after relaunch. Files imported via
UIDocumentPickerViewControllerrequire a security-scoped bookmark. Store it withurl.bookmarkData(options: .minimalBookmark); a rawURLin SwiftData will become inaccessible after the next cold launch. - Slider jitter from the periodic observer. If you write
currentPositionevery second while the user is dragging, the scrubber jumps. Track anisDraggingflag and skip the SwiftData write while the thumb is in motion. - App Store guideline 4.2 — replicated functionality. Reviewers sometimes reject audiobook apps that appear identical to Apple Books with no distinct use case. Lead with a clear genre angle (public domain, language learning, podcast lectures) in your App Store description and screenshots.
- AVAudioSession not activated before play. Calling
player.play()beforesetActive(true)passes silently in Simulator but can fail on device when another app currently owns the audio session.
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.