Struggling with NotificationCenter/Combine in SwiftUI/AVPlayer
I found one solution for similar issue:
- I created the new subclass of
AVPlayer
; - Added observer to
currentItem
; - 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
Swift Firebase Custom Object with Document Id
Swiftui Sheet Not Updating Variable
Swift Extension Storage for Protocols
Swift Combine Publishers VS Completion Handler and When to Cancel
Swift Alternative to Respondstoselector:
Realitykit - How to Edit or Add a Lighting
How to Use a Value Type Object as a Reference Type
Does Untimeintervalnotificationtrigger Nexttriggerdate() Give the Wrong Date
Why Does Type(Of:) Return Metatype, Rather Than T.Type
Create Spotlight-Like Window in Swift 4
How to Replace Limited Number of Occurrences in String
Ios/Tvos Playground Fails with "Unable to Find Execution Service for Selected Run Destination"
Formsheet iOS 8 Constraints Are Same as Iphones Constraints
Swift Notification Fire from Datepicker
Type '()' Cannot Conform to 'View'
Xcode Swift. How to Programmatically Select Cell in View-Based Nstableview