FRadioPlayer 0.2.1: Migrating from delegates to observers

FRadioPlayer used to have one delegate. If a station list was listening for playback changes and a full-screen player assigned itself as the delegate, the station list stopped receiving them. Keeping both updated meant forwarding events yourself.

In 0.2.1, I replaced that slot with an observer API. Each listener registers independently. The release also adds duration, playback progress, and seeking for on-demand audio, along with a SwiftUI demo.

The examples below use the 0.2.1 API. If you're installing a newer release, check its README for the current requirements and behavior.

Replacing the delegate

Remove the delegate assignment, conform to FRadioPlayerObserver, and register the listener:

// Before 0.2.1
FRadioPlayer.shared.delegate = self

// 0.2.1 and later
FRadioPlayer.shared.addObserver(self)   // conforms to FRadioPlayerObserver
// When updates are no longer required
FRadioPlayer.shared.removeObserver(self)

The player holds observers weakly, so registering one doesn't keep it alive. Your app still needs to retain its view model or controller. Call removeObserver when a listener should stop receiving events, even if that object will stay alive.

Internally, a dictionary keyed by ObjectIdentifier stores those weak references. Registering the same object again replaces its entry rather than adding a second subscription. The implementation is in FRadioPlayer+Observation.swift.

John Sundell's article on observation protocols was a reference for this design. It walks through the same approach with an audio player.

Implementing the observer protocol

The protocol provides empty defaults for its callbacks, so implement the ones your screen needs. This example publishes loading state and playback progress for a SwiftUI view:

import Combine
import Foundation
import FRadioPlayer

final class RadioViewModel: ObservableObject, FRadioPlayerObserver {
  @Published private(set) var isBuffering = false
  @Published private(set) var progress: Double = 0

  private let player = FRadioPlayer.shared

  init(url: URL) {
    player.addObserver(self)
    player.radioURL = url
  }

  func radioPlayer(
    _ player: FRadioPlayer,
    playerStateDidChange state: FRadioPlayer.State
  ) {
    isBuffering = (state == .loading)
  }

  func radioPlayer(
    _ player: FRadioPlayer,
    playTimeDidChange currentTime: TimeInterval,
    duration: TimeInterval
  ) {
    progress = duration > 0 ? currentTime / duration : 0
  }

  deinit {
    player.removeObserver(self)
  }
}

Keep the model alive with @StateObject in the view that owns it. Other views can observe that model, or use their own registered listeners when they need different state.

In 0.2.1, progress callbacks are for audio with a known duration. Live streams report a duration of zero and don't get periodic playback-time updates. Show live playback state there instead of a scrubber.

Streaming files with seeking

For a stream with a known duration, such as a hosted MP3 episode, duration and currentTime give you what you need for a progress bar. Use seek(to:completion:) to move to a position in seconds:

player.seek(to: 42) {
  // Update UI once the new position is ready
}

In this release, seeking starts playback when the seek completes, including if the player was paused. Account for that behavior when wiring up a scrubber. A seek on a stream with zero duration returns without calling the completion handler.

When a file finishes, the player pauses and seeks back to the beginning, ready to be played again.

Swift Package Manager and the demo

I also removed the CocoaPods, Carthage, and Travis CI setup. Swift Package Manager is the supported installation path for this release.

The 0.2.1 package declares Swift tools 5.5. The SwiftUI demo targets iOS 15+ and lives in Example/FRadioPlayerDemo/. With XcodeGen installed, generate its project from the repo root:

cd Example
xcodegen
open FRadioPlayerDemo.xcodeproj

The demo shares player state between views through environment objects. GitHub Actions builds the package and generates and builds the demo, so both forms of the project are checked.

Migration checklist

For an existing app:

  1. Remove any player.delegate = self assignments.
  2. Conform to FRadioPlayerObserver and register each listener with addObserver.
  3. Keep each listener alive for as long as it needs updates, and unregister it when it's done.
  4. Add durationDidChange or playTimeDidChange callbacks if the app needs progress controls for on-demand audio.
  5. Move to Swift Package Manager if you're using CocoaPods or Carthage.
  6. Test with the station list and player open in turn. Both should keep receiving the events they need.

FRadioPlayer is the audio engine behind Swift Radio, but it doesn't require Swift Radio's interface. If you're using it in another app and the migration leaves something unclear, open an issue. A small example of the old delegate code helps.