How to Build a Podcast Player App in SwiftUI
A Podcast Player app lets users subscribe to RSS feeds, stream episodes via AVPlayer, and resume playback exactly where they left off across sessions. It's a strong App Store product for developers comfortable with async audio pipelines, XML parsing, and recurring subscriptions.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Comfort with Swift concurrency (async/await) — RSS fetching and XML parsing rely on it heavily
- A physical iOS device for testing background audio; AVAudioSession playback behavior is unreliable in Simulator
Architecture overview
Podcast data — feeds and episodes — lives in SwiftData with a cascade-delete relationship. AVPlayer is wrapped in an @Observable view model that manages the audio session lifecycle, lock screen controls via MPNowPlayingInfoCenter, and playback position persistence back into the model context on a timer. Views form a three-screen stack: the library (feed list), an episode list, and a sheet-style mini-player. RSS feeds are fetched with URLSession and parsed on a background actor using XMLParser to keep the main thread free.
PodcastApp/ ├── Models/ │ ├── PodcastFeed.swift (@Model, SwiftData) │ └── Episode.swift (@Model, SwiftData) ├── Views/ │ ├── LibraryView.swift │ ├── EpisodeListView.swift │ └── PlayerView.swift ├── Services/ │ ├── RSSParser.swift (background actor) │ └── PlayerViewModel.swift (@Observable) └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define PodcastFeed and Episode as SwiftData @Model classes so subscriptions, episode metadata, and per-episode playback positions all survive app restarts.
import SwiftData
import Foundation
@Model final class PodcastFeed {
var id: UUID = UUID()
var title: String = ""
var feedURL: String = ""
var artworkURL: String = ""
@Relationship(deleteRule: .cascade)
var episodes: [Episode] = []
}
@Model final class Episode {
var id: UUID = UUID()
var title: String = ""
var audioURL: String = ""
var publishDate: Date = Date()
var duration: TimeInterval = 0
var playbackPosition: TimeInterval = 0
var isPlayed: Bool = false
}
2. Core UI — podcast library
Build the library view with a SwiftData-driven List and AsyncImage for artwork so subscribers see their feeds the moment the view appears.
struct PodcastLibraryView: View {
@Query(sort: \PodcastFeed.title) private var feeds: [PodcastFeed]
@State private var showAdd = false
var body: some View {
NavigationStack {
List(feeds) { feed in
NavigationLink(destination: EpisodeListView(feed: feed)) {
HStack(spacing: 12) {
AsyncImage(url: URL(string: feed.artworkURL)) { img in
img.resizable().aspectRatio(contentMode: .fill)
} placeholder: { Color.secondary.opacity(0.15) }
.frame(width: 54, height: 54)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(feed.title).font(.headline).lineLimit(1)
Text("\(feed.episodes.count) episodes")
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Library")
.toolbar { Button("Add", systemImage: "plus") { showAdd = true } }
.sheet(isPresented: $showAdd) { AddFeedView() }
}
}
}
3. Subscription and playback with AVPlayer
Wrap AVPlayer in an @Observable view model that activates the .playback audio session, resumes from the saved position, and exposes a toggle so the UI stays in sync.
import AVFoundation
import MediaPlayer
@Observable final class PlayerViewModel {
private var player: AVPlayer?
private(set) var isPlaying = false
private(set) var currentTime: TimeInterval = 0
var currentEpisode: Episode?
func play(_ episode: Episode) {
guard let url = URL(string: episode.audioURL) else { return }
if currentEpisode?.id != episode.id {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .spokenAudio)
try? session.setActive(true)
player = AVPlayer(url: url)
let seek = CMTime(seconds: episode.playbackPosition, preferredTimescale: 600)
player?.seek(to: seek)
}
currentEpisode = episode
player?.play()
isPlaying = true
}
func togglePlayPause() {
isPlaying ? player?.pause() : player?.play()
isPlaying.toggle()
}
}
4. Privacy Manifest
Add PrivacyInfo.xcprivacy to your app target — required since the iOS 17 SDK — declaring UserDefaults access and the purchase history your StoreKit subscription collects.
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key><false/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePurchaseHistory</string>
<key>NSPrivacyCollectedDataTypeLinked</key><true/>
<key>NSPrivacyCollectedDataTypeTracking</key><false/>
<key>NSPrivacyCollectedDataTypeUsePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Audio stops when the screen locks. You must call
AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)and enable the "Audio, AirPlay, and Picture in Picture" background mode under Signing & Capabilities. Forgetting either one silences playback the moment the screen dims. - AVPlayer time observer accumulation. Always store the token returned by
addPeriodicTimeObserverand callremoveTimeObserver(_:)before swapping the player instance — multiple live observers fire callbacks even after the episode changes. - XMLParser blocking the main thread.
XMLParseris synchronous. Run it inside aTask.detachedor a@globalActor-isolated function; a feed with hundreds of episodes will visibly freeze the UI if parsed inline. - App Store rejection: no Restore Purchases button. Apple Guideline 3.1.1 requires a visible restore mechanism for any subscription. Reviewers will reject without it — add
Button("Restore Purchases") { try? await AppStore.sync() }in your paywall. - App Store rejection: missing privacy policy URL. Subscription apps must include a hosted privacy policy URL in both the app UI and the App Store Connect listing. A blank field is one of the most common reasons subscription apps bounce in review.
Adding monetization: Subscription
Implement auto-renewable subscriptions with StoreKit 2. Create a subscription group in App Store Connect, define monthly and annual SKUs, then load them at runtime with Product.products(for: skuIDs). Gate premium features — offline downloads, ad-free playback, unlimited subscriptions — behind a Transaction.currentEntitlement(for:) check. Attach a .subscriptionStatusTask(for:) modifier to your root view so entitlement state refreshes automatically on renewal, cancellation, or billing-grace-period events. Listen to Transaction.updates as a long-lived async stream to handle edge cases, and finish every transaction with transaction.finish() to clear the queue.
Shipping this faster with Soarias
Soarias scaffolds the full Xcode project with the correct entitlements (background audio, StoreKit in-app purchases) already wired, pre-configures the AVAudioSession setup code, auto-generates your PrivacyInfo.xcprivacy based on the features you select, and builds a fastlane lane for App Store screenshots and ASC submission — all before you write a line of feature code.
An advanced podcast player typically takes 2–4 weeks to build and ship solo, with a meaningful chunk of that time lost to entitlements, manifest wiring, and submission plumbing. Soarias compresses that setup to under an hour, giving back roughly 4–6 days that you can spend on RSS parsing quality, playback UX polish, and optimizing your subscription paywall conversion.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. The $99/year Apple Developer Program membership is required to distribute on TestFlight, submit to the App Store, and use StoreKit auto-renewable subscriptions in production. You can build and sideload on a personal device with a free account, but you cannot publish without membership.
How do I submit this to the App Store?
Archive the app in Xcode via Product → Archive, then distribute through the Xcode Organizer or run fastlane deliver. In App Store Connect, fill in the metadata, screenshots, privacy nutrition labels, and your subscription pricing. Submit for review and expect 1–3 business days. If you have an active subscription in-app purchase, attach it to the version before submitting.
Can users download episodes for offline playback?
Yes. Use URLSession.downloadTask(with:) to fetch the audio file and save the resulting local URL into the Episode model. On playback, check episode.localFileURL first and fall back to episode.audioURL for streaming. Track download progress with URLSessionDownloadDelegate and expose it via a published property in your view model.
Last reviewed: 2026-05-12 by the Soarias team.