FRadioPlayer 0.4.0: Swift 6 and playback that respects pause
FRadioPlayer 0.4.0 is out! This release brings the library to Swift 6, with improvements to playback behavior and compatibility with apps still using Swift 5. Here’s what changed and what to know before upgrading.
In FRadioPlayer 0.4.0, I moved the player to Swift 6 and made those playback decisions explicit. Player state and observer callbacks now live on the main actor, with fixes for seeking, interruptions, stale artwork, and audio-session setup.
FRadioPlayer is the audio engine behind Swift Radio. I extracted it from the app in 2017 so it could be used without the radio interface. This update keeps that separation: your app supplies the UI, and the library handles playback.
Swift 6 at the callback boundary
FRadioPlayer is now @MainActor. Its state and observer callbacks belong to the same actor, so a controller can update its UI from those callbacks directly.
AVFoundation doesn't deliver every callback there. KVO, seek completions, and end-of-item notifications need their own handling. Off-main KVO arrives through a nonisolated entry point, gets reduced to Sendable values, and crosses to the main actor before changing state. Events from an item that's already been replaced are ignored.
Metadata takes a different path. The library explicitly registers its metadata delegate on the main queue, and keeps the AVTimedMetadataGroup payload there. That conformance uses @preconcurrency to bridge the AVFoundation protocol. The observer protocol also uses it to preserve compatibility with existing conformers.
Those annotations have specific jobs. The migration is about checking where callbacks arrive and what can cross the boundary, then keeping player state on the main actor.
Playback follows the listener
Seeking used to start playback when it completed, even if the player was paused. In 0.4.0, moving to another position keeps the current playback intent. A pause or stop while the seek is in flight wins too.
If your scrubber is meant to resume playback after seeking, call play() explicitly. The library no longer makes that UI choice for you.
Seek completion now runs exactly once, asynchronously on the main actor, including when no item is loaded or its duration isn't known yet. In those cases, the completion still runs even though no seek takes place.
Interruptions follow the same rule. Playback resumes only if it was active when the interruption began, the system allows resumption, and the listener hasn't since paused or stopped.
There are fixes around that state too. Late artwork from an earlier station or track is discarded. A failed load reports .error without first sending .loadingFinished, and retrying a failed stream recovers from the asset-loading problem found on iOS 27.
The simulator missed an audio-session bug
The old audio-session setup passed .allowAirPlay and .allowBluetoothA2DP alongside the .playback category. On a physical iPhone, that combination caused category setup to fail. The simulator tests hadn't caught it.
The fix was to use .playback with its default mode and no explicit options. AirPlay and Bluetooth A2DP are already supported by that category; the extra options weren't needed.
I checked playback on the iPhone and with CarPlay, and tested the demo app too. The automated suites cover state handling, including interruption and route-change notifications, but those checks don't replace listening on real hardware.
Upgrading an app
Update the Swift Package Manager dependency if it's pinned to 0.3.0 or restricted to the 0.3 minor series:
.package(url: "https://github.com/fethica/FRadioPlayer.git", from: "0.4.0")
You'll need Xcode 16 or later and a Swift 6 toolchain. Your app can stay in Swift 5 language mode, but calls to the player still need to run on the main actor. The library's platform minimums remain iOS 14, macOS 11, and tvOS 14.
For a controller that owns playback, the basic setup looks like this:
import Foundation
import FRadioPlayer
@MainActor
final class RadioController {
private let player = FRadioPlayer.shared
func play(_ url: URL) {
player.radioURL = url
player.play()
}
}
From a nonisolated callback, use Task { @MainActor in ... } to call the player. If your app manages its own audio session, set FRadioPlayer.configuresAudioSession = false on the main actor before the first access to shared. The library configures the category by default; it doesn't activate or deactivate the session for your app.
The migration guide covers observer callbacks, artwork providers, and seeking in more detail. The release notes have the full changelog. If the upgrade leaves a playback case unclear, open an issue with the sequence that triggers it. Whether playback was paused, stopped, or interrupted is a useful place to start.