Struggling with Notificationcenter/Combine in Swiftui/Avplayer

Struggling with NotificationCenter/Combine in SwiftUI/AVPlayer

I found one solution for similar issue:

  1. I created the new subclass of AVPlayer;
  2. Added observer to currentItem;
  3. Override func observeValue, where add observer for current item when player reach end time;

Here is simplified example:

import AVKit // for player
import Combine // for observing and adding as environmentObject

final class AudioPlayer: AVPlayer, ObservableObject {

var songDidEnd = PassthroughSubject<Void, Never>() // you can use it in some View with .onReceive function

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

private func registerObserves() {
self.addObserver(self, forKeyPath: "currentItem", options: [.new], context: nil)
// example of using
}

override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
// currentItem could be nil in the player. I add observer to exist item
if keyPath == "currentItem", let item = currentItem {
NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinishPlaying(_:)), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: item)

// another way, using Combine
var cancellable: AnyCancellable?
cancellable = NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: item).sink { [weak self] _ in
self?.songDidEnd.send()
cancellable?.cancel()
}
}
// other observers
}

@objc private func playerDidFinishPlaying(_ notification: Notification) {
playNextSong() // my implementation, here you can just write: "self.pause()"
}

}

UPDATE: simple example of using .onReceive (be careful, I wrote it without playground/Xcode, so it can have errors):

struct ContentView: View {

@EnvironmentObject var audioPlayer: AudioPlayer
@State private var someText: String = "song is playing"

var body: some View {
Text(someText)
.onReceive(self.audioPlayer.songDidEnd) { // maybe you need "_ in" here
self.handleSongDidEnd()
}
}

private func handleSongDidEnd() {
print("song did end")
withAnimation {
someText = "song paused"
}
}

}

About Combine with AVPlayer: you can look at my question, there you'll see some ways to observe playing time and functionality to rewind time with slider in SwiftUI.

I'm using one instance of AudioPlayer, controlling play/pause functions or changing currentItem (which means setting another song) like this:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

// other staff
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let homeView = ContentView()
.environmentObject(AudioPlayer())

// other staff of SceneDelegate
}
}

SwiftUI Combine for .onReceive notifications with AVPlayer

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")
}
}
}

}

SwiftUI with NotificationCenter publishers

@State is not ready yet in init, so it cannot be used for such purposes. The approach can be as follows:

var cancellables = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)
.sink(receiveValue: { _ in
print(">> in init")
})
.store(in: &cancellables)
}

in such defined cancellables you can store all subscribers created in init, but you will not be able to use it later in code, but this approach is good for once defined notification handlers.

How to control AVPlayer in SwiftUI?

I didn't find any solution, so tried to do it on my own. I learned Combine framework a little, inherited AVPlayer class and signed it under the protocol ObservableObject and used KVO. May be it's not the best solution, but it works, hope somebody will give me advices for improving the code in future. Here are some code snippets:

import Foundation
import AVKit
import Combine

final class AudioPlayer: AVPlayer, ObservableObject {

@Published var currentTimeInSeconds: Double = 0.0
private var timeObserverToken: Any?
// ... some other staff

// MARK: Publishers
var currentTimeInSecondsPass: AnyPublisher<Double, Never> {
return $currentTimeInSeconds
.eraseToAnyPublisher()
}

// in init() method I add observer, which update time in seconds
override init() {
super.init()
registerObserves()
}

private func registerObserves() {

let interval = CMTime(seconds: 1, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
timeObserverToken = self.addPeriodicTimeObserver(forInterval: interval, queue: .main) {
[weak self] _ in
self?.currentTimeInSeconds = self?.currentTime().seconds ?? 0.0
}

}

// func for rewind song time
func rewindTime(to seconds: Double) {
let timeCM = CMTime(seconds: seconds, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
self.seek(to: timeCM)
}

// sure I need to remove observer:
deinit {

if let token = timeObserverToken {
self.removeTimeObserver(token)
timeObserverToken = nil
}

}

}

// simplified slider

import SwiftUI

struct PlayerSlider: View {

@EnvironmentObject var player: AudioPlayer
@State private var currentPlayerTime: Double = 0.0
var song: Song // struct which contains the song length as Int

var body: some View {

HStack {

GeometryReader { geometry in
Slider(value: self.$currentPlayerTime, in: 0.0...Double(self.song.songLength))
.onReceive(self.player.currentTimeInSecondsPass) { _ in
// here I changed the value every second
self.currentPlayerTime = self.player.currentTimeInSeconds
}
// controlling rewind
.gesture(DragGesture(minimumDistance: 0)
.onChanged({ value in
let coefficient = abs(Double(self.song.songLength) / Double(geometry.size.width))
self.player.rewindTime(to: Double(value.location.x) * coefficient)
}))
}
.frame(height: 30)

}

}

}

update for VolumeView
For volume control I made new UIViewRepresentable struct:

import SwiftUI
import UIKit
import MediaPlayer

struct MPVolumeViewRepresenter: UIViewRepresentable {

func makeUIView(context: Context) -> MPVolumeView {

let volumeView = MPVolumeView()
volumeView.showsRouteButton = false // TODO: 'showsRouteButton' was deprecated in iOS 13.0: Use AVRoutePickerView instead.
if let sliderView = volumeView.subviews.first as? UISlider {
// custom design colors
sliderView.minimumTrackTintColor = UIColor(red: 0.805, green: 0.813, blue: 0.837, alpha: 1)
sliderView.thumbTintColor = UIColor(red: 0.805, green: 0.813, blue: 0.837, alpha: 1)
sliderView.maximumTrackTintColor = UIColor(red: 0.906, green: 0.91, blue: 0.929, alpha: 1)
}

return volumeView

}

func updateUIView(_ uiView: MPVolumeView, context: UIViewRepresentableContext<MPVolumeViewRepresenter>) {
// nothing here. really, nothing
}

}

// and you can use it like:
struct VolumeView: View {

var body: some View {

HStack(alignment: .center) {
Image("volumeDown")
.renderingMode(.original)
.resizable()
.frame(width: 24, height: 24)

MPVolumeViewRepresenter()
.frame(height: 24)
.offset(y: 2) // centering

Image("volumeUp")
.renderingMode(.original)
.resizable()
.frame(width: 24, height: 24)

}.padding(.horizontal)

}

}


Related Topics



Leave a reply



Submit