SwiftUI Combine for .onReceive notifications with AVPlayer

Viewed 2443

I'm working in SwiftUI and have an AudioPlayer type that is a subclass of AVPlayer; it publishes AVPlayer's timeControllerStatus(?) (.playing, .paused, and others?). Rather than subclassing AVPlayer, I would like to pass in an AVPlayer and have it notify me using .onReceive in some View. Here is the current, functional type I have:

import AVKit
import Combine

class AudioPlayer: AVPlayer, ObservableObject {
    @Published var buffering: Bool = false

    override init() {
        super.init()
        registerObservers()
    }

    private func registerObservers() {
        self.addObserver(self, forKeyPath: "timeControlStatus", options: [.old, .new], context: nil)
    }

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {

        if keyPath == "timeControlStatus", let change = change, let newValue = change[NSKeyValueChangeKey.newKey] as? Int, let oldValue = change[NSKeyValueChangeKey.oldKey] as? Int {
            let oldStatus = AVPlayer.TimeControlStatus(rawValue: oldValue)
            let newStatus = AVPlayer.TimeControlStatus(rawValue: newValue)
            if newStatus != oldStatus {
                DispatchQueue.main.async {[weak self] in
                    if newStatus == .playing || newStatus == .paused {
                        self?.buffering = false
                    } else {
                        self?.buffering = true
                    }
                }
            }
        }
    }
}

And here is an example of a class like the one I would like (taken from Chris Mash's tutorial on SwiftUI & AVPlayer):

import Combine
import AVFoundation

class PlayerItemObserver {
    let publisher = PassthroughSubject<Bool, Never>()
    private var itemObservation: NSKeyValueObservation?

    init(player: AVPlayer) {
        // Observe the current item changing
        itemObservation = player.observe(\.currentItem) { [weak self] player, change in
            guard let self = self else { return }
            // Publish whether the player has an item or not
            self.publisher.send(player.currentItem != nil)
        }
    }

    deinit {
        if let observer = itemObservation {
            observer.invalidate()
        }
    }
}

Your help is much appreciated.

2 Answers

As I understand, you need to observe timeControlStatus just like in article example. For this you can just replace observer:

import Combine
import AVFoundation

class PlayerItemObserver {

    let controlStatusChanged = PassthroughSubject<AVPlayer.TimeControlStatus, Never>()
    private var itemObservation: NSKeyValueObservation?

    init(player: AVPlayer) {

        itemObservation = player.observe(\.timeControlStatus) { [weak self] player, change in
            guard let self = self else { return }
            self.controlStatusChanged.send(player.timeControlStatus)
        }

    }

    deinit {
        if let observer = itemObservation {
            observer.invalidate()
        }
    }
}

// MARK: init view
let player = AudioPlayer()
let playerObserver = PlayerItemObserver(player: player)
let contentView = SongListView(playerObserver: playerObserver)

// MARK: react on changing in view:
struct ContentView: View {

    let playerObserver: PlayerItemObserver

    var body: some View {
        Text("Any view")
            .onReceive(playerObserver.controlStatusChanged) { newStatus in
                switch newStatus {
                case .waitingToPlayAtSpecifiedRate:
                    print("waiting")
                case .paused:
                    print("paused")
                case .playing:
                    print("playing")
                }
        }
    }

}

UPDATE you can achieve the same without "old school" observe, using @Published and AnyCancellable. The last one even don't need extra code in deinit. Here is this solution:

import Combine
import AVFoundation

class PlayerItemObserver {

    @Published var currentStatus: AVPlayer.TimeControlStatus?
    private var itemObservation: AnyCancellable?

    init(player: AVPlayer) {

        itemObservation = player.publisher(for: \.timeControlStatus).sink { newStatus in
            self.currentStatus = newStatus
        }

    }

}

// MARK: you need to change view with new observation, but in general it will be the same
struct ContentView: View {

    let playerObserver: PlayerItemObserver

    var body: some View {
        Text("Any view")
            .onReceive(playerObserver.$currentStatus) { newStatus in
                switch newStatus {
                case nil:
                    print("nothing is here")
                case .waitingToPlayAtSpecifiedRate:
                    print("waiting")
                case .paused:
                    print("paused")
                case .playing:
                    print("playing")
                }
        }
    }

}

NSObject has a method that gives you a Publisher for any KVO-compliant property. It's not documented, but it was discussed in WWDC 2019 sessions. For example, Raleigh Ledet described it starting at 25m36s in Session 231: Introducing SwiftUI, and Michael LeHew used it at 11m47s in Session 721: Combine in Practice.

The method is declared like this:

public func publisher<Value>(
    for keyPath: KeyPath<Self, Value>,
    options:NSKeyValueObservingOptions = [.initial, .new]
) -> NSObject.KeyValueObservingPublisher<Self, Value>

So, for example, you can use it like this:

player.publisher(for: \.timeControlStatus, options: [.initial])
    .sink { print("player status: \($0)") }
    .store(in: &tickets)
Related