feat: add notification controls (#3723)
* feat(ios): add `showNotificationControls` prop * feat(android): add `showNotificationControls` prop * add docs * feat!: add `metadata` property to srouce This is breaking change for iOS/tvOS as we are moving some properties, but I believe that this will more readable and more user friendly * chore(ios): remove UI blocking function * code review changes for android * update example * fix readme * fix typos * update docs * fix typo * chore: improve sample metadata notification * update codegen types * rename properties * update tvOS example * reset metadata on source change * update docs --------- Co-authored-by: Olivier Bouillet <freeboub@gmail.com>
This commit is contained in:
281
ios/Video/NowPlayingInfoCenterManager.swift
Normal file
281
ios/Video/NowPlayingInfoCenterManager.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
|
||||
class NowPlayingInfoCenterManager {
|
||||
static let shared = NowPlayingInfoCenterManager()
|
||||
|
||||
private let SEEK_INTERVAL_SECONDS: Double = 10
|
||||
|
||||
private weak var currentPlayer: AVPlayer?
|
||||
private var players = NSHashTable<AVPlayer>.weakObjects()
|
||||
|
||||
private var observers: [Int: NSKeyValueObservation] = [:]
|
||||
private var playbackObserver: Any?
|
||||
|
||||
private var playTarget: Any?
|
||||
private var pauseTarget: Any?
|
||||
private var skipForwardTarget: Any?
|
||||
private var skipBackwardTarget: Any?
|
||||
private var playbackPositionTarget: Any?
|
||||
private var seekTarget: Any?
|
||||
|
||||
private var receivingRemoveControlEvents = false {
|
||||
didSet {
|
||||
if receivingRemoveControlEvents {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
} else {
|
||||
UIApplication.shared.endReceivingRemoteControlEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
func registerPlayer(player: AVPlayer) {
|
||||
if players.contains(player) {
|
||||
return
|
||||
}
|
||||
|
||||
if receivingRemoveControlEvents == false {
|
||||
receivingRemoveControlEvents = true
|
||||
}
|
||||
|
||||
if let oldObserver = observers[player.hashValue] {
|
||||
oldObserver.invalidate()
|
||||
}
|
||||
|
||||
observers[player.hashValue] = observePlayers(player: player)
|
||||
players.add(player)
|
||||
|
||||
if currentPlayer == nil {
|
||||
setCurrentPlayer(player: player)
|
||||
}
|
||||
}
|
||||
|
||||
func removePlayer(player: AVPlayer) {
|
||||
if !players.contains(player) {
|
||||
return
|
||||
}
|
||||
|
||||
if let observer = observers[players.hashValue] {
|
||||
observer.invalidate()
|
||||
}
|
||||
|
||||
observers.removeValue(forKey: players.hashValue)
|
||||
players.remove(player)
|
||||
|
||||
if currentPlayer == player {
|
||||
currentPlayer = nil
|
||||
updateMetadata()
|
||||
}
|
||||
|
||||
if players.allObjects.isEmpty {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
public func cleanup() {
|
||||
observers.removeAll()
|
||||
players.removeAllObjects()
|
||||
|
||||
if let playbackObserver {
|
||||
currentPlayer?.removeTimeObserver(playbackObserver)
|
||||
}
|
||||
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
invalidateTargets(commandCenter)
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
|
||||
receivingRemoveControlEvents = false
|
||||
}
|
||||
|
||||
private func setCurrentPlayer(player: AVPlayer) {
|
||||
if player == currentPlayer {
|
||||
return
|
||||
}
|
||||
|
||||
if let playbackObserver {
|
||||
currentPlayer?.removeTimeObserver(playbackObserver)
|
||||
}
|
||||
|
||||
currentPlayer = player
|
||||
registerTargets()
|
||||
|
||||
updateMetadata()
|
||||
|
||||
// one second observer
|
||||
playbackObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(value: 1, timescale: 4),
|
||||
queue: .global(),
|
||||
using: { [weak self] _ in
|
||||
self?.updatePlaybackInfo()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func registerTargets() {
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
invalidateTargets(commandCenter)
|
||||
|
||||
playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
|
||||
guard let self, let player = self.currentPlayer else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
if player.rate == 0 {
|
||||
player.play()
|
||||
}
|
||||
|
||||
return .success
|
||||
}
|
||||
|
||||
pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||
guard let self, let player = self.currentPlayer else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
if player.rate != 0 {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
return .success
|
||||
}
|
||||
|
||||
skipBackwardTarget = commandCenter.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
guard let self, let player = self.currentPlayer else {
|
||||
return .commandFailed
|
||||
}
|
||||
let newTime = player.currentTime() - CMTime(seconds: SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
|
||||
player.seek(to: newTime)
|
||||
return .success
|
||||
}
|
||||
|
||||
skipForwardTarget = commandCenter.skipForwardCommand.addTarget { [weak self] _ in
|
||||
guard let self, let player = self.currentPlayer else {
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
let newTime = player.currentTime() + CMTime(seconds: SEEK_INTERVAL_SECONDS, preferredTimescale: .max)
|
||||
player.seek(to: newTime)
|
||||
return .success
|
||||
}
|
||||
|
||||
playbackPositionTarget = commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let self, let player = self.currentPlayer else {
|
||||
return .commandFailed
|
||||
}
|
||||
if let event = event as? MPChangePlaybackPositionCommandEvent {
|
||||
player.seek(to: CMTime(seconds: event.positionTime, preferredTimescale: .max)) { _ in
|
||||
player.play()
|
||||
}
|
||||
return .success
|
||||
}
|
||||
return .commandFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func invalidateTargets(_ commandCenter: MPRemoteCommandCenter) {
|
||||
commandCenter.playCommand.removeTarget(playTarget)
|
||||
commandCenter.pauseCommand.removeTarget(pauseTarget)
|
||||
commandCenter.skipForwardCommand.removeTarget(skipForwardTarget)
|
||||
commandCenter.skipBackwardCommand.removeTarget(skipBackwardTarget)
|
||||
commandCenter.changePlaybackPositionCommand.removeTarget(playbackPositionTarget)
|
||||
}
|
||||
|
||||
public func updateMetadata() {
|
||||
guard let player = currentPlayer, let currentItem = player.currentItem else {
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
invalidateTargets(commandCenter)
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
|
||||
return
|
||||
}
|
||||
|
||||
// commonMetadata is metadata from asset, externalMetadata is custom metadata set by user
|
||||
let metadata = currentItem.asset.commonMetadata + currentItem.externalMetadata
|
||||
var nowPlayingInfo: [String: Any] = [:]
|
||||
|
||||
if let titleItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierTitle).first?.value {
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = titleItem
|
||||
} else {
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = ""
|
||||
}
|
||||
|
||||
if let artistItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierArtist).first?.value {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = artistItem
|
||||
} else {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = ""
|
||||
}
|
||||
|
||||
// I have some issue with this - setting artworkItem when it not set dont return nil but also is crashing application
|
||||
// this is very hacky workaround for it
|
||||
if let artworkItem = AVMetadataItem.metadataItems(from: metadata, filteredByIdentifier: .commonIdentifierArtwork).first?.value as? Data {
|
||||
if let image = UIImage(data: artworkItem) {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ in
|
||||
return image
|
||||
})
|
||||
}
|
||||
} else {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: UIImage().size, requestHandler: { _ in
|
||||
UIImage()
|
||||
})
|
||||
}
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
|
||||
private func updatePlaybackInfo() {
|
||||
guard let player = currentPlayer, let currentItem = player.currentItem else {
|
||||
return
|
||||
}
|
||||
|
||||
// We dont want to update playback if we did not set metadata yet
|
||||
if var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo {
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentItem.duration.seconds
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentItem.currentTime().seconds.rounded()
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
}
|
||||
|
||||
private func findNewCurrentPlayer() {
|
||||
if let newPlayer = players.allObjects.first(where: {
|
||||
$0.rate != 0
|
||||
}) {
|
||||
setCurrentPlayer(player: newPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
// We will observe players rate to find last active player that info will be displayed
|
||||
private func observePlayers(player: AVPlayer) -> NSKeyValueObservation {
|
||||
return player.observe(\.rate) { [weak self] player, change in
|
||||
guard let self else { return }
|
||||
|
||||
let rate = change.newValue
|
||||
|
||||
// case where there is new player that is not paused
|
||||
// In this case event is triggered by non currentPlayer
|
||||
if rate != 0 && currentPlayer != player {
|
||||
setCurrentPlayer(player: player)
|
||||
return
|
||||
}
|
||||
|
||||
// case where currentPlayer was paused
|
||||
// In this case event is triggeret by currentPlayer
|
||||
if rate == 0 && currentPlayer == player {
|
||||
findNewCurrentPlayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user