FRadioPlayer is a wrapper around AVPlayer to handle internet radio playback.
SwiftUI demo source lives under Example/FRadioPlayerDemo/.
Use XcodeGen to generate and open the demo project:
brew install xcodegen # once
cd Example
xcodegen # generates FRadioPlayerDemo.xcodeproj
open FRadioPlayerDemo.xcodeproj- Support internet radio URL playback
- Update and parse track metadata
- Update and show album artwork (via iTunes API)
- Automatic handling of interruptions
- Automatic handling of route changes
- Support bluetooth playback
- Network interruptions handling
- Automatic stall recovery with bounded retries (reload with backoff, clean error state when the stream is gone)
- Support for Swift Package Manager SPM
- iOS 14.0+
- macOS 11.0+
- tvOS 14.0+
- Xcode 16+ (Swift 6 toolchain; the package builds in Swift 6 language mode, your app can stay in Swift 5 mode)
FRadioPlayer is available through SPM. To add it in Xcode: File > Add Packages… and use the URL of this repository. Or add the dependency in Package.swift:
.package(url: "https://github.com/fethica/FRadioPlayer.git", from: "0.4.0")Add the package, then use the shared player and observe changes.
import FRadioPlayer
// Adopting FRadioPlayerObserver here makes the class main-actor isolated
final class RadioController: NSObject, FRadioPlayerObserver {
let player = FRadioPlayer.shared
override init() {
super.init()
player.addObserver(self)
player.enableArtwork = true // Optional (default true)
player.isAutoPlay = true // Optional (default true)
player.radioURL = URL(string: "https://your.station/stream.mp3")
// Or manually control playback: player.play()
}
func radioPlayer(_ player: FRadioPlayer, playerStateDidChange state: FRadioPlayer.State) {
print("Player state: \(state)")
}
}
// From a nonisolated context, hop to the main actor
Task { @MainActor in
FRadioPlayer.shared.togglePlaying() // Play/Pause
FRadioPlayer.shared.stop() // Stop
FRadioPlayer.shared.volume = 0.8 // Set volume (0.0...1.0)
}Prefer SPM. If needed, drag Sources/FRadioPlayer into your Xcode project.
Since 0.4.0 FRadioPlayer is bound to the main actor and the package builds in Swift 6 language mode.
- Call the player from the main actor. From a background context, hop first:
Task { @MainActor in FRadioPlayer.shared.play() }. - Observer callbacks arrive on the main actor. A class that adopts
FRadioPlayerObserverin its declaration is inferred@MainActor. A conformance added in an extension keeps the type's own isolation and still compiles. - A controller that calls the player from its own methods must be main-actor isolated. Add
@MainActorto the class, as the demo'sRadioPlayerdoes. This applies in Swift 5 mode too: calling an isolated method from a nonisolated one is an error, not a warning. FRadioPlayer.StateandFRadioPlayer.PlaybackStateareSendable.FRadioPlayer.Metadatais not, because it carriesAVTimedMetadataGroupvalues; keep it on the main actor.- Custom
FRadioArtworkAPIproviders receive a@Sendablecompletion and may call it from any queue. Existing providers written with a plain completion keep compiling. - AVFoundation and audio session callbacks that can arrive off the main thread are received
nonisolatedinside the library and hopped explicitly. Nothing is marked@unchecked Sendableornonisolated(unsafe).
See the 0.4.0 release notes and migration guide when upgrading from 0.3.x.
On iOS and tvOS the player sets the shared AVAudioSession category to .playback, with the default mode and no category options, when FRadioPlayer.shared is first accessed. The playback category supports AirPlay and Bluetooth A2DP without additional options on iOS.
An app that owns its audio session opts out before its first access to shared, for example in its App initializer or application(_:didFinishLaunchingWithOptions:):
FRadioPlayer.configuresAudioSession = falseSetting it after shared exists has no effect. The player never activates or deactivates the session.
- Import
FRadioPlayer
import FRadioPlayer- Get the singleton
FRadioPlayerinstance
let player = FRadioPlayer.shared- Observe player events (optional)
final class MyObserver: NSObject, FRadioPlayerObserver {
override init() {
super.init()
FRadioPlayer.shared.addObserver(self)
}
func radioPlayer(_ player: FRadioPlayer, playerStateDidChange state: FRadioPlayer.State) {
// handle state change
}
}- Set the radio URL
player.radioURL = URL(string: "http://example.com/station.mp3")isAutoPlay: BoolAuto-play whenradioURLis set (defaulttrue).enableArtwork: BoolFetch album artwork via iTunes API (defaulttrue).artworkAPI: FRadioArtworkAPIArtwork provider, defaultiTunesAPI(artworkSize: 300).rate: Float?CurrentAVPlayerrate.isPlaying: BoolConvenience read-only state.state: FRadioPlayer.StatePlayer state.playbackState: FRadioPlayer.PlaybackStatePlayback state.volume: Float?Player volume, 0.0…1.0.httpHeaderFields: [String:String]?HTTP headers for the underlyingAVURLAsset.metadataExtractor: FRadioMetadataExtractorStrategy to parse timed metadata.currentMetadata: FRadioPlayer.Metadata?Last parsed timed metadata.currentArtworkURL: URL?Last resolved artwork URL.duration: TimeIntervalTotal duration, 0 for live streams.currentTime: DoubleCurrent playback time in seconds.FRadioPlayer.configuresAudioSession: Bool(static) Whether the player sets the audio session category, defaulttrue. See Audio session.
- Play
player.play()- Pause
player.pause()- Stop
player.stop()- Toggle playing state
player.togglePlaying()- Seek (files with a known duration)
player.seek(to: 30) {
// Called exactly once, on the main actor
}The completion is always called exactly once, on the main actor, after seek returns. That includes a live stream or an item whose duration isn't known yet (no seek happens), no loaded item, and a seek that is superseded by a newer one. Seeking keeps the playback intent: a playing player keeps playing, a paused or stopped player stays that way, and a pause issued while the seek is in flight wins.
For live streams, pause() keeps the item and its connection, and resumes from the buffered position. stop() drops the connection and clears metadata and artwork; the next play() reconnects.
- Player state
func radioPlayer(_ player: FRadioPlayer, playerStateDidChange state: FRadioPlayer.State)- Playback state
func radioPlayer(_ player: FRadioPlayer, playbackStateDidChange state: FRadioPlayer.PlaybackState)- Item change
func radioPlayer(_ player: FRadioPlayer, itemDidChange url: URL?)- Timed metadata
func radioPlayer(_ player: FRadioPlayer, metadataDidChange metadata: FRadioPlayer.Metadata?)- Artwork URL
func radioPlayer(_ player: FRadioPlayer, artworkDidChange artworkURL: URL?)- Duration and time updates
func radioPlayer(_ player: FRadioPlayer, durationDidChange duration: TimeInterval)
func radioPlayer(_ player: FRadioPlayer, playTimeDidChange currentTime: TimeInterval, duration: TimeInterval)For more complete app features, check out Swift Radio App based on FRadioPlayer
This repository uses Swift Package Manager for building and testing:
swift build
swift testThe test suite covers the public API contract, metadata extraction, the artwork API (stubbed, no network), playback against bundled fixtures, and state machine regressions. CI runs it on macOS and on an iOS simulator for pull requests, pushes to main and tags. Bug fixes should come with a failing test first.
FRadioPlayer is available under the MIT license. See the LICENSE file for more info.

